Filter - CS50x 2023
实现一个程序,该程序将滤镜效果应用到 BMP 图像,如下所示。
$ ./filter -r IMAGE.bmp REFLECTED.bmp
其中 IMAGE.bmp
是图像文件的名称,REFLECTED.bmp
是输出图像文件的名称,现在已镜像处理。
背景
位图
表示图像最简单的方法可能是使用像素(即点)网格,每个像素可以是不同的颜色。 对于黑白图像,因此我们需要每个像素 1 位,因为 0 可以表示黑色,1 可以表示白色,如下所示。
因此,从某种意义上说,图像就是一个位图(即位的映射)。 对于颜色更丰富的图像,您只需要每个像素更多的位。 支持“24 位颜色”的文件格式(如 BMP、JPEG 或 PNG)每个像素使用 24 位。(BMP 实际上支持 1 位、4 位、8 位、16 位、24 位和 32 位颜色。)
24 位 BMP 使用 8 位分别表示像素颜色中的红色、绿色和蓝色分量。 如果您听说过 RGB 颜色,那么这里指的就是:红色、绿色和蓝色。
如果 BMP 中某个像素的 R、G 和 B 值(例如)是十六进制的 0xff
、0x00
和 0x00
,则该像素是纯红色,因为 0xff
(也称为十进制的 255
)表示“大量红色”,而 0x00
和 0x00
分别表示“没有绿色”和“没有蓝色”。
位图技术详解
请回忆一下,文件本质上是以某种方式排列的一系列二进制位。 那么,一个 24 位 BMP 文件本质上只是一系列二进制位,其中(几乎)每 24 位都代表某个像素的颜色。 但是,BMP 文件还包含一些“元数据”,例如图像的高度和宽度等信息。 该元数据以两种数据结构的形式存储在文件的开头,通常称为“标头”,不要与 C 的头文件混淆。(值得一提的是,这些标头是随着时间推移而不断演变的。此问题使用 Microsoft BMP 格式的最新版本 4.0,该版本随 Windows 95 首次亮相。)
这些标头中的第一个,称为 BITMAPFILEHEADER
,长 14 个字节。(回想一下,1 个字节等于 8 位。)这些标头中的第二个,称为 BITMAPINFOHEADER
,长 40 个字节。 紧随这些标头之后的是实际的位图数据:一个字节数组,每三个字节代表一个像素的颜色。 但是,BMP 以相反的顺序存储这些三元组(即,作为 BGR),其中 8 位用于蓝色,然后是 8 位用于绿色,然后是 8 位用于红色。(某些 BMP 还会以倒序存储整个位图数据,即图像的顶行存储在 BMP 文件的末尾。但是,我们已将此问题集的 BMP 存储在此处描述的方式中,每个位图的顶行在前,底行在后。)换句话说,如果我们要将上面的 1 位笑脸转换为 24 位笑脸,用红色代替黑色,则 24 位 BMP 将按如下方式存储此位图,其中 0000ff
表示红色,ffffff
表示白色; 我们用红色高亮显示了所有 0000ff
的示例。
因为我们将这些位从左到右、从上到下,以8列的形式排列,所以稍微后退几步,你就能看到红色的笑脸图案。
为了更清楚地说明,请记住,一个十六进制位代表4个二进制位。因此,十六进制的ffffff
实际上对应于二进制的111111111111111111111111
。
注意,可以将位图表示为像素的二维数组:图像由行组成,每一行又是由像素组成的数组。 实际上,这就是我们在本问题中表示位图图像的方法。
图像滤镜
图像滤波到底是什么意思? 你可以这样理解图像滤波:获取原始图像的像素,然后以某种方式修改每个像素,从而在结果图像中产生特定的效果。
灰度
一种常见的滤镜是“灰度”滤镜,它将图像转换为黑白图像。 它是如何实现的呢?
回想一下,如果红色、绿色和蓝色的值都设置为 0x00
(十六进制,表示 0),那么该像素就是黑色的。 如果所有值都设置为 0xff
(十六进制,表示 255),那么该像素就是白色的。 只要红色、绿色和蓝色的值相等,结果就会呈现出黑白光谱中的不同灰度,数值越高,阴影越浅 (越接近白色),数值越低,阴影越深 (越接近黑色)。
因此,要将像素转换为灰度,我们只需要保证红色、绿色和蓝色的值相同即可。 那么,应该将它们设置为哪个值呢? 比较合理的做法是,如果原始的红色、绿色和蓝色值都比较高,那么新的值也应该比较高;如果原始值都比较低,那么新的值也应该比较低。
实际上,为了确保新图像中每个像素的整体亮度与原始图像大致相同,我们可以计算红色、绿色和蓝色值的平均值,以此来确定新像素的灰度值。
将此方法应用于图像中的每个像素,就能得到灰度图像。
反射
有些滤镜还会移动像素。 例如,图像反射是一种滤镜,其效果就像将原始图像放在镜子前一样。 因此,图像左侧的像素会出现在右侧,反之亦然。
需要注意的是,原始图像中的所有像素仍然会出现在反射图像中,只不过它们的位置发生了改变。
模糊
有很多方法可以实现模糊或柔化图像的效果。 在本问题中,我们将使用“盒状模糊”方法,该方法通过获取每个像素,并对相邻像素的颜色值取平均,从而得到该像素新的颜色值。
每个像素的新值,将是其周围一圈像素 (上下左右各一个像素,形成一个 3x3 的区域) 的颜色值的平均值。 例如,像素 6 的每个颜色值,是通过计算像素 1、2、3、5、6、7、9、10 和 11 的原始颜色值的平均值得到的 (注意,像素 6 本身也包含在内)。 同样,像素 11 的颜色值,是通过计算像素 6、7、8、10、11、12、14、15 和 16 的颜色值的平均值得到的。
对于位于边缘或角落的像素,例如像素 15,我们仍然查找其周围一圈的像素 (上下左右各一个像素):在这种情况下,涉及的像素为 10、11、12、14、15 和 16。
边缘检测
在图像处理的人工智能算法中,检测图像边缘很有用,边缘指的是图像中构成物体边界的线条。实现此效果的一种方法是将 Sobel 算子 (索贝尔算子) 应用于图像。
与图像模糊类似,边缘检测也是通过获取每个像素,并根据其周围的 3x3 像素网格进行修改。但索贝尔算子并非简单地计算周围九个像素的平均值,而是通过加权求和计算每个像素的新值。由于物体边缘可能出现在水平和垂直方向,因此需要计算两个加权和:分别用于检测 x 和 y 方向的边缘。具体来说,您将使用以下两个“卷积核”:
如何理解这些卷积核?例如,要计算像素红色通道的 Gx
值,取该像素周围 3x3 区域内九个像素的原始红色值,分别乘以 Gx
卷积核中对应位置的权重,然后求和。
为什么卷积核采用这些特定值?例如,在 Gx
方向上,我们将目标像素右侧的像素乘以正权重,左侧的像素乘以负权重。求和后,如果左右两侧像素颜色相近,结果将接近于 0 (正负抵消)。反之,如果左右两侧像素差异很大,结果值将为很大的正数或负数,表明颜色突变,可能存在物体边界。类似的原理也适用于 y
方向的边缘检测。
使用这些卷积核,可以计算像素红、绿、蓝三个通道的 Gx
和 Gy
值。但每个通道只能有一个值,因此需要将 Gx
和 Gy
合并为一个值。索贝尔滤波器算法通过计算 Gx^2 + Gy^2
的平方根,得到最终的边缘强度值。由于通道值范围为 0 到 255 的整数,计算结果需要四舍五入取整,并限制在 255 以内!
那么,图像边缘或角落的像素该如何处理呢?处理边缘像素的方法有很多,但为了简化问题,请将图像视为边缘有一圈 1 像素宽的纯黑色边框。因此,访问图像边界之外的像素应视为纯黑色 (RGB 值为 0)。这相当于在计算 Gx
和 Gy
时忽略了这些边界外的像素。
开始
登录 cs50.dev,单击您的终端窗口,然后单独执行 cd
。您应该发现您的终端窗口的提示符类似于以下内容:
接下来执行
wget https://cdn.cs50.net/2022/fall/psets/4/filter-more.zip
以便将名为 filter-more.zip
的 ZIP 文件下载到您的 codespace 中。
然后执行
创建名为 filter-more
的文件夹。您不再需要 ZIP 文件,因此您可以执行
并在提示符下回复“y”,然后按 Enter 键以删除您下载的 ZIP 文件。
现在输入
然后按 Enter 键将自己移动到(即打开)该目录。您的提示符现在应类似于以下内容。
执行 ls
命令,你应该会看到一些文件:bmp.h
、filter.c
、helpers.h
、helpers.c
和 Makefile
。你还会看到一个名为 images
的文件夹,其中包含四个 BMP 文件。如果你遇到任何问题,请再次按照这些步骤操作,看看你能不能找到哪里出错了!
理解
现在我们来看看提供的这些代码文件,了解一下它们的内容。
bmp.h
在文件浏览器里双击打开 bmp.h
并查看。
你会看到我们之前提到的头文件 BITMAPINFOHEADER
和 BITMAPFILEHEADER
的定义。此外,这个文件还定义了 BYTE
、DWORD
、LONG
和 WORD
等数据类型,这些类型常见于 Windows 编程。注意,它们只是你(应该)已经熟悉的原始类型的别名。BITMAPFILEHEADER
和 BITMAPINFOHEADER
结构体中使用了这些类型。
对你来说,最重要的可能是这个文件还定义了一个名为 RGBTRIPLE
的结构体。它简单地“封装”了三个字节:蓝色、绿色和红色(记住,这是我们在磁盘上找到RGB三元组的顺序)。
这些 struct
结构体有什么用呢?回想一下,文件在磁盘上就是一系列的字节(或者说是位)。这些字节通常按照一定的顺序排列:前几个字节代表一种信息,接下来的几个字节代表另一种信息,以此类推。文件格式之所以存在,是因为业界已经标准化了每个字节的含义。我们可以直接把文件从磁盘读取到内存里,作为一个大的字节数组。我们可以记住 array[i]
的字节代表一种信息,array[j]
的字节代表另一种信息。但是,为什么不给这些字节命名,方便我们从内存中读取呢?而 bmp.h
中的结构体正是为了方便我们做这件事。与其把文件看作一个长的字节序列,不如看作一系列的 struct
结构体。
filter.c
现在,我们打开 filter.c
文件。这个文件已经写好了,但有几个重点需要注意。
首先,注意第10行 filters
的定义。这个字符串定义了程序允许的命令行参数:b
、e
、g
和 r
。它们分别对应不同的图像滤镜:模糊、边缘检测、灰度化和反射。
接下来的代码打开图像文件,确认它是 BMP 格式,并将所有像素数据读取到名为 image
的二维数组中。
向下滚动到第101行的 switch
语句。注意,根据选择的 filter
参数,会调用不同的函数:b
对应 blur
,e
对应 edges
,g
对应 grayscale
,r
对应 reflect
。同时,这些函数都接收图像的高度、宽度和像素二维数组作为参数。
这些就是你接下来要实现的函数。可以想到,目标是让这些函数修改像素的二维数组,从而实现所需的滤镜效果。
程序的剩余部分将处理后的 image
写入新的图像文件。
helpers.h
接下来,查看 helpers.h
文件。这个文件很短,只包含了之前提到的函数的原型声明。
这里请注意,每个函数都接收一个名为 image
的二维数组作为参数。image
数组包含 height
行,每一行又包含 width
个 RGBTRIPLE
元素。所以,如果把 image
看作是整张图片,那么 image[0]
就是第一行,image[0][0]
则是左上角的那个像素点。
helpers.c
现在,打开 helpers.c
。这里是 helpers.h
中声明的函数的具体实现所在。不过要注意,这些函数的具体实现现在是空的! 剩下的就靠你自己了。
Makefile
最后,让我们看看 Makefile
。此文件指定了当我们运行像 make filter
这样的终端命令时应该发生什么。你之前写的程序可能只有一个文件,但 filter
却用了好几个:filter.c
和 helpers.c
。所以,我们需要告诉 make
怎么编译这些文件。
尝试通过转到终端并运行以下命令来自己编译 filter
:
然后,你可以通过运行以下命令来运行该程序:
$ ./filter -g images/yard.bmp out.bmp
这条命令会读取 images/yard.bmp
这张图片,然后用 grayscale
函数处理每个像素,最后生成一个叫做 out.bmp
的新图片。不过,因为 grayscale
现在还没写任何东西,所以输出的图片应该和原来的一模一样。
规范
你需要自己在 helpers.c
里实现这些函数,这样用户才能对图片应用灰度、反射、模糊或者边缘检测这些滤镜。
grayscale
函数应接受一个图像,并将其转换为同一图像的黑白版本。reflect
函数应接受一个图像,并将其水平翻转。blur
函数应接受一个图像,并将其转换为同一图像的盒状模糊版本。edges
函数应接受一个图像,并根据 Sobel 算子突出显示对象之间的边缘。
除了 helpers.c
之外,其他文件和函数的定义都不要改动。
演练
请注意,此播放列表中有 5 个视频。
用法
你的程序应该像下面这些例子一样运行。INFILE.bmp
是输入图像的名称,OUTFILE.bmp
是应用滤镜后生成的图像的名称。
$ ./filter -g INFILE.bmp OUTFILE.bmp
$ ./filter -r INFILE.bmp OUTFILE.bmp
$ ./filter -b INFILE.bmp OUTFILE.bmp
$ ./filter -e INFILE.bmp OUTFILE.bmp
提示
rgbtRed
、rgbtGreen
和rgbtBlue
这几个颜色分量都是整数,所以如果计算结果是小数,记得四舍五入成整数再赋值!
测试
一定要用提供的这些示例图片测试一下你写的滤镜!
运行下面的命令可以用 check50
检查代码是否正确。 不过,别忘了先自己编译运行测试一下!
check50 cs50/problems/2023/x/filter/more
运行下面的命令可以用 style50
检查代码风格。
如何提交
在终端里运行下面的命令来提交你的代码。
submit50 cs50/problems/2023/x/filter/more